/** * WorthDoing.ai * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: src/app/api/investigations/[id]/events/route.ts * Description: SSE stream of real AgentEvents with Last-Event-ID replay; proxy-safe (no buffering, per-event flush). */ import { NextRequest } from "next/server"; import { z } from "zod"; import { subscribe, eventsAfter, type AgentEvent } from "@/lib/agent/events"; export const dynamic = "force-dynamic"; const HEARTBEAT_MS = 15_000; export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { const { id } = await ctx.params; if (!z.string().uuid().safeParse(id).success) { return new Response("Invalid id", { status: 400 }); } const lastEventIdHeader = req.headers.get("last-event-id") ?? req.nextUrl.searchParams.get("lastEventId"); const afterSeq = lastEventIdHeader ? parseInt(lastEventIdHeader, 10) || 0 : 0; const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { let closed = false; const send = (event: AgentEvent) => { if (closed) return; try { controller.enqueue( encoder.encode(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`), ); } catch { closed = true; } }; // Live subscription first so nothing falls in the gap, then replay history. const seen = new Set(); const buffer: AgentEvent[] = []; let replaying = true; const unsubscribe = subscribe(id, (e) => { if (replaying) buffer.push(e); else if (!seen.has(e.seq)) { seen.add(e.seq); send(e); } }); const history = await eventsAfter(id, afterSeq); for (const e of history) { seen.add(e.seq); send(e); } replaying = false; for (const e of buffer) { if (!seen.has(e.seq)) { seen.add(e.seq); send(e); } } const heartbeat = setInterval(() => { if (closed) return; try { controller.enqueue(encoder.encode(`: heartbeat ${Date.now()}\n\n`)); } catch { closed = true; } }, HEARTBEAT_MS); const cleanup = () => { closed = true; clearInterval(heartbeat); unsubscribe(); try { controller.close(); } catch { // already closed } }; req.signal.addEventListener("abort", cleanup); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }, }); }